ci(partof-guard): the closing-keyword gate reads every commit message on the PR - #16371
Merged
baozhoutao merged 1 commit intoSep 6, 2026
Merged
Conversation
… on the PR The gate judged one of the two surfaces GitHub's reference parser acts on. It read the PR body; it never read the commit messages, and this repo squash-merges, so the message that lands on the default branch is assembled at merge time by concatenating them. That text is written by nobody and reviewed by nobody: it carries every trailer its inputs carried, and it can contradict itself where none of its parts did. The specimen is the squash of PR 16247, commit fc3fb7c, an ancestor of the default branch: three bullets, a closing trailer for a card inside the first bullet's body, and a third bullet retracting a claim the first still makes. No body-side rule could have seen it -- the contradictory text existed in no body -- and the sweep's H23, which patrols this surface after merge, binds narrower and is silent on it: H23 reports only the Part-of plus closing-keyword contradiction, and this specimen declares no Part-of at all. So the gate now enforces a second, strictly wider rule: no commit on the PR may carry a card-relation trailer at all. Width is what makes it enforceable -- "trailers that would contradict each other once concatenated" is a property of an assembly that does not exist until the merge button, while "no trailer in any commit" is a property of one commit. An assembly cannot manufacture what none of its inputs contain. The relation extractors are the sweep's, imported at `markdown: false` and not re-spelled: a fourth spelling of the closing-keyword grammar would be a fourth thing for the parity gate to hold in step, and the commit-message reading is a measured contract of that sweep rather than this gate's call. The workflow gathers the list and hands it over as a file path, so the judging path stays HTTP-free by construction. The endpoint is read rather than `git log base..head` walked: it returns exactly the set GitHub will squash, while the walk needs a merge base this depth-1 checkout does not have and would report another author's landed trailers as this PR's. An absent, malformed or empty commit list exits 2 and says which rule judged nothing. A wiring that forgot the commits has not seen a clean commit history, and the two must never print the same line. Self-test batteries 9 -> 15, cases 28 -> 66, with the real squash message as the regression fixture. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vbw3RPgdtqesx4azk9SbW8
baozhoutao
marked this pull request as ready for review
September 6, 2026 16:05
baozhoutao
enabled auto-merge
September 6, 2026 16:05
baozhoutao
deleted the
claude/issue-16158-partof-gate-reads-commit-messages
branch
September 6, 2026 16:39
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #16158
No changeset:
skip-changeset. Repo tooling only — one gate script and its wiringworkflow. Nothing is published from any package, so there is nothing for a changeset to
describe. (The label is the PM's to apply; this seat cannot.)
What was wrong
The gate judged one of the two surfaces GitHub's reference parser acts on. It read the
PR body; it never read the commit messages. This repo squash-merges, so the commit that
lands on the default branch is assembled at merge time by concatenating the branch's
commit messages — a text written by nobody and reviewed by nobody. It carries every trailer
its inputs carried, and it can contradict itself where none of its parts did.
The specimen is PR 16247's squash
fc3fb7c4619, an ancestor of the default branch: threebullets concatenated from three commit messages, a closing trailer for card 16121 sitting
inside the first bullet's body, and a third bullet that retracts a claim the first bullet
still makes. The landed message asserts and withdraws the same thing in one text.
Two existing readers both miss it, and for structural reasons rather than by oversight:
manufactured by the assembly — so the body was clean, correctly so.
Part-of-plus-closing-keyword contradiction, and this specimen declares no Part-of at
all, so H23 is silent on exactly this shape. H23 is also post-merge and report-only.
What this does
The gate now enforces a second, strictly wider rule: no commit on the PR may carry a
card-relation trailer at all — no closing keyword, no Part-of, no Refs, bound to any card
number. The PR body is the only carrier of the relation.
Width is what makes it enforceable. "Trailers that would contradict each other once
concatenated" is a property of an assembly that does not exist until the merge button, so it
cannot be judged commit by commit. "No trailer in any commit" is a property of one
commit — and an assembly cannot manufacture what none of its inputs contain.
No fourth parser. The relation extractors are the half-state sweep's, imported and
called at
markdown: false, not re-spelled. Two things ride on that:check-closing-keyword-parityholds three spellings of GitHub's grammar behaviourally equal, and a fourth would be one more
to keep in step; and the
markdown: falsereading (a commit message is not markdown, sobackticks do not neutralise a keyword there) is a measured contract of that sweep, not
this gate's call to re-make.
The judging path stays HTTP-free. The workflow gathers the commit list and hands it over
as a file path in
env:; the script still makes no request.Why the endpoint and not
git log base..headRecorded here and in the workflow comments because it is a real trade, and it overrides the
route the dispatch suggested. The commits endpoint returns exactly the set GitHub will
squash. The git walk needs the merge base present in order to exclude what is already on the
default branch, and this job checks out at depth 1 — so on a branch that has merged the
default branch back in, a shallow walk cannot perform that exclusion and would report
another author's landed trailers as this PR's. The alternatives are a deepen-until-found
loop (unbounded) or
fetch-depth: 0(a full clone to read a handful of messages). The costis one added read scope,
pull-requests: read.Presence semantics: an unread half can never print green
An absent, malformed or empty commit list exits
2and names which rule judged nothing.A wiring that forgot the commits has not seen a clean commit history — it has seen no commit
history, and the two must never print the same line. Zero rows is read as a failed gather,
not as a PR with no commits, because every PR has at least one. When a real finding and a
half-wired run coincide, the finding wins the exit code and the unread half is still
named in the output.
Verification
Head sha
bbe84648f. Self-test batteries 9 → 15, cases 28 → 66.Self-test, exit code captured before any pipe:
The three drives of the real gate,
(a)clean,(b)the fixture,(c)nothing set:A fourth drive,
(d)body wired but commits not, exits2withPARTLY WIRED. The fixturerow in
(b)is not a hand-written fixture: it is the real landed squash message, read out ofthis repository with
git log -1 --format=%B fc3fb7c4619.The gate also clears its own PR: run against this branch's commits and this body, it exits
0.Ablation — three legs, each mutation confirmed on disk before reading
Every leg restored with
git checkout HEAD -- <path>, proven by blob-hash equality againstthe HEAD blob and an empty
git diff HEAD. Direction predicted before running: red.relations.length === 0→>= 0)markdown: false→trueon the closing-keyword readA1 is the load-bearing one: the fixture drive flips to green exactly when the rule is
removed, which is what makes the
(b)reading a measurement rather than a coincidence. A2and A3 leave the fixture at 1 correctly — the fixture's trailer sits in plain prose, so
neither the empty-list path nor code-stripping can hide it; the cases they redden are the
presence-semantics and backtick/fence delegation ones.
A first attempt at A1 used a multi-line anchor, which
grep -cFcounted as two lines; theharness aborted the leg as a would-be no-op rather than reporting an unmeasured green, and
the leg was re-run with a unique single-line anchor. Recorded because the abort is the
harness working, not a flake.
Gates
All 47 commands derived by
scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackagainst the real diff were run: 47/47 exit 0. Notable members, given what this change
touches:
check-closing-keyword-parity(0 — confirms no fourth parser of the grammar wasintroduced and the sweep still finds every spelling registered),
check:partof-closing-keyword,check:pm-dispatch-gates,check-self-test-wired,check-self-test-workflow-commands,check-step-collectors,check:watch-hint-literal,check:nul-bytes,check:required-contexts.Lint is a narrowed scan, declared:
eslint --no-inline-config --format jsonover thechanged script reports 1 file, 0 errors, 0 warnings (count read from the JSON output).
The narrowing excludes nothing, and that is a property of the config rather than an
assumption —
eslint.config.mjsstates that this repo runs one flat config which "neverenables type-aware linting (no
parserOptions.project, no typed@typescript-eslintrules)for ANY file", so this diff cannot move the verdict on any untouched file. The workflow YAML
is outside eslint's population. The repo-wide sweep is CI's run.
Control characters:
grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]'over both changed filesreturns no hits, beyond
check:nul-bytesbeing green.Notes for review
ghis not installed in the agentcontainer, so the gather step's
gh api --paginate ... --jqline was validated byreading and by YAML parse, not by execution. This PR's own CI run is its first live
exercise — worth a look at that step's log before this leaves draft.
H23 measured, roughly 15% of landed messages carried a closing-keyword binding, so this is
not a rule that will never fire. It is what the ruling asked for, and the agent protocol
already requires trailer-free commits; the remedy is never a history rewrite, and the
finding text says so explicitly.
already written (
.claude/agents/os-dev.md, quoted verbatim and untranslated) instead ofrestating it, and names the repair the ruling names: the body states the relation once, the
merger takes the squash message from the body.
synchronizewas already in the trigger set, so a reworded push re-judges;editediskept for the body half.
Generated by Claude Code